07.1 精通自定义 View 之 绘图进阶——贝济埃曲线

返回自定义 View 目录

7.1.1 概述

在 Path 的系列函数中,除了一些基本的设置和绘图用法外,还有一个强大的工具——贝济埃曲线。它能将利用 moveTo、lineTo 连接的生硬路径变得平滑,也能够实现很多炫酷的效果,比如水波纹等。

1. 贝赛尔曲线来源

贝塞尔曲线于 1962 年,由法国工程师皮埃尔·贝塞尔(Pierre Bézier)所广泛发表,他运用贝塞尔曲线来为汽车的主体进行设计。贝塞尔曲线最初由 Paul de Casteljau 于 1959 年运用 de Casteljau 算法开发,以稳定数值的方法求出贝塞尔曲线。

在数学的数值分析领域中,贝赛尔曲线(Bézier 曲线)是电脑图形学中相当重要的参数曲线。更高维度的广泛化贝塞尔曲线就称作贝塞尔曲面,其中贝塞尔三角是一种特殊的实例。

2. 贝济埃曲线公式

1)一阶贝济埃曲线

P0 为起点、P1 为终点,t 表示当前时间,B(t) 表示公式的结果值。

注意,曲线的意义就是公式结果 B(t) 随时间的变化,其取值所形成的轨迹。在动画中,黑色点表示在当前时间 t 下公式 B(t) 的取值。而红色的那条线就不在各个时间点下不同取值的 B(t) 所形成的轨迹。

总而言之:对于一阶贝济埃曲线,大家可以理解为在起始点和终点形成的这条直线上,匀速移动的点。

2)二阶贝济埃曲线

在这里 P0 是起始点,P2 是终点,P1 是控制点。假设将时间定在 t=0.25 的时刻,此时的状态如下图所示:

首先,P0 点和 P1 点形成了一条贝济埃曲线,还记得我们上面对一阶贝济埃曲线的总结么:就是一个点在这条直线上做匀速运动;所以 P0-P1 这条直线上的移动的点就是 Q0。

同样,P1、P2 形成了一条一阶贝济埃曲线,在这条一阶贝济埃曲线上,它们的随时间移动的点是 Q1。

最后,动态点 Q0 和 Q1 又形成了一条一阶贝济埃曲线,在它们这条一阶贝济埃曲线动态移动的点是 B。而 B 的移动轨迹就是这个二阶贝济埃曲线的最终形态。从上面的讲解大家也可以知道,之所以叫它二阶贝济埃曲线是因为,B 的移动轨迹是建立在两个一阶贝济埃曲线的中间点 Q0、Q1 的基础上的。

在理解了二阶贝赛尔曲线的形成原理以后,我们就不难理解三阶贝赛尔曲线了。

3)三阶贝济埃曲线

同样,我们取其中一点来讲解轨迹的形成原理,当 t=0.25 时,此时状态如下:

同样,P0 是起始点,P3 是终点;P1 是第一个控制点,P2 是第二个控制点。

首先,这里有三条一阶贝济埃曲线,分别是 P0-P1、P1-P2、P2-P3,他们随时间变化的点分别为 Q0、Q1、Q2。然后是由 Q0、Q1、Q2 这三个点,再次连接,形成了两条一阶贝济埃曲线,分别是Q0-Q1、Q1-Q2,他们随时间变化的点为 R0、R1。

同样,R0 和 R1 同样可以连接形成一条一阶贝济埃曲线,在 R0-R1 这条贝赛尔曲线上随时间移动的点是 B,而 B 的移动轨迹就是这个三阶贝济埃曲线的最终形状。

从上面的解析大家可以看出,所谓几阶贝济埃曲线,全部是由一条条一阶贝济埃曲线搭起来的。在上图中,形成一阶贝济埃曲线的直线是灰色的,形成二阶贝济埃曲线线是绿色的,形成三阶贝济埃曲线的线是蓝色的。

在理解了上面的二阶和三阶贝济埃曲线以后,我们再来看几个贝济埃曲线的动态图。

4)四阶贝济埃曲线

5)五阶贝济埃曲线

3. 贝济埃曲线与 PhotoShop 钢笔工具

在专业绘图工具 Photoshop 中,有一个钢笔工具,它使用的路径弯曲效果就是二阶贝济埃曲线,下面利用 Photoshop 的钢笔工具来得出二阶贝济埃曲线的相关控制点。

我们拿最终成形的图形来看一下为什么钢笔工具是二阶贝济埃曲线:

右图演示的假设某一点 t=0.25 时,动态点 B 的位置图。同样,这里 P0 是起始点,P2 是终点,P1 是控制点。P0-P1、P1-P2 形成了第一层的一阶贝济埃曲线。它们随时间的动态点分别是 Q0、Q1;动态点 Q0、Q1 又形成了第二层的一阶贝济埃曲线,它们的动态点是 B。而 B 的轨迹跟钢笔工具的形状是完全一样的,所以说钢笔工具的拉伸效果使用的是二阶贝济埃曲线。

这里需要注意的是,我们在使用钢笔工具时,拖动的是 P5 点。其实二阶贝济埃曲线的控制点是其对面的 P1 点,钢笔工具这样设计是当然是因为操作起来比较方便。

7.1.2 贝济埃曲线之 quadTo

在 Path 类中有四个方法与贝济埃曲线相关,分别是:

1
2
3
4
5
6
// 二阶济埃尔
public void quadTo(float x1, float y1, float x2, float y2)
public void rQuadTo(float dx1, float dy1, float dx2, float dy2)
// 三阶济埃尔
public void cubicTo(float x1, float y1, float x2, float y2,float x3, float y3)
public void rCubicTo(float x1, float y1, float x2, float y2,float x3, float y3)

在这四个函数中 quadTo、rQuadTo 是二阶贝济埃曲线,cubicTo、rCubicTo 是三阶贝济埃曲线;我们这篇文章以二阶贝济埃曲线的 quadTo、rQuadTo 为主,三阶贝济埃曲线 cubicTo、rCubicTo 用的使用方法与二阶贝济埃曲线类似,用处也比较少,这篇就不再细讲了。

1. quadTo 使用原理

1
public void quadTo(float x1, float y1, float x2, float y2)

参数中 (x1,y1) 是控制点坐标,(x2,y2) 是终点坐标。
大家可能会有一个疑问:有控制点和终点坐标,那起始点是多少呢?
整条线的起始点是通过 Path.moveTo(x,y) 来指定的,而如果我们连续调用 quadTo(),前一个 quadTo() 的终点,就是下一个 quadTo() 函数的起点;如果初始没有调用 Path.moveTo(x,y) 来指定起始点,则默认以控件左上角(0,0)为起始点。大家可能还是有点迷糊,下面我们就举个例子来看看,我们利用 quadTo() 来画下面的这条波浪线:

下面分析一下,在这条路径轨迹中,控制点分别在哪个位置,如下图所示。

我们先看 P0-P2 这条轨迹,P0 是起点,假设位置坐标是 (100,300),P2 是终点,假充位置坐标是 (300,300);在以 P0 为起始点,P2 为终点这条二阶贝济埃曲线上,P1 是控制点,很明显 P1 大概在 P0、P2 中间的位置,所以它的 X 坐标应该是 200,关于 Y 坐标,我们无法确定,但很明显的是 P1 在 P0、P2 点的上方,也就是它的 Y 值比它们的小,所以根据钢笔工具上面的位置,我们让 P1 的比 P0、P2 的小 100,所以 P1的坐标是 (200,200)。

同理,不难求出在 P2-P4 这条二阶贝济埃曲线上,它们的控制点 P3 的坐标位置应该是 (400,400)。

所以我们就可以自定义一个控件,并重写它的 onDraw() 函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
public class TestView extends View {
private Paint mPaint;
private Path mPath;
public TestView(Context context, @Nullable AttributeSet attrs) {
super(context, attrs);
mPaint = new Paint();
mPaint.setStrokeWidth(4);
mPaint.setColor(Color.GRAY);
mPaint.setStyle(Paint.Style.STROKE);
mPaint.setAntiAlias(true);
mPath = new Path();
}
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
mPath.moveTo(100, 300);
mPath.quadTo(200, 200, 300, 300);
mPath.quadTo(400, 400, 500, 300);
canvas.drawPath(mPath, mPaint);
}
}

通过这个例子希望大家知道两点:

  • 整条线的起始点是通过 Path.moveTo(x,y) 来指定的,如果初始没有调用 Path.moveTo(x,y) 来指定起始点,则默认以控件左上角 (0,0) 为起始点。
  • 如果我们连续调用 quadTo(),前一个 quadTo() 的终点,就是下一个 quadTo() 函数的起点。

2. 示例:手指轨迹

要实现手指轨迹其实是非常简单的,我们只需要在自定义中拦截 OnTouchEvent,然后根据手指的移动轨迹来绘制 Path 即可。最简单的方法就是直接使用 Path.lineTo() 就能实现把各个点连接起来。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
public class TestView extends View {
private Paint mPaint;
private Path mPath;
public TestView(Context context, @Nullable AttributeSet attrs) {
super(context, attrs);
mPaint = new Paint();
mPaint.setColor(Color.GREEN);
mPaint.setStyle(Paint.Style.STROKE);
mPaint.setStrokeWidth(4);
mPath = new Path();
}
@Override
public boolean onTouchEvent(MotionEvent event) {
switch (event.getAction()){
case MotionEvent.ACTION_DOWN:
mPath.moveTo(event.getX(), event.getY());
return true;
case MotionEvent.ACTION_MOVE:
mPath.lineTo(event.getX(), event.getY());
invalidate();
break;
default:
break;
}
return super.onTouchEvent(event);
}
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
canvas.drawPath(mPath, mPaint);
}
}

虽然实现了画出手指的移动轨迹,但我们仔细来看看画出来的图:

我们把轨迹放大,明显看出,在两个点连接处有明显的转折,而且在轨迹顶部位置横纵坐标变化比较快的位置,看起来跟图片这大后的马赛克一样;利用 Path 绘图,是不可能出现马赛克的,因为除了 Bitmap 以外的任何 canvas 绘图全部都是矢量图,也就是利用数学公式来作出来的图,无论放在多大屏幕上,都不可能会出现马赛克。这里利用 Path 绘图,在轨迹顶部之所以看起来像是马赛克是因为这个轨迹是由各个不同点之间连线写出来的,而之间并没有平滑过渡,所以当坐标变化比较剧烈时,线与线之间的转折就显得特别明显了。

所以要想优化这种效果,就得实现线与线之间的平滑过渡,很显然,二阶贝济埃曲线就是干这个事的。下面我们就利用我们新学的 Path.quadTo 函数来重新实现下移动轨迹效果。

3. 优化:使用 Path.quadTo() 函数实现手势过渡

使用 Path.lineTo() 的最大问题就是线段转折处不够平滑。Path.quadTo() 可以实现平滑过渡,但使用 Path.quadTo() 的最大问题是,如何找到起始点和结束点。

下图中,有用绿点表示的三个点,连成的两条直线,很明显他们转折处是有明显折痕的

下面我们在 PhotoShop 中利用钢笔工具,看如何才能实现这两条线之间的转折。

最终的贝济埃曲线连接如下图所示。

从这两个线段中可以看出,我们使用 Path.lineTo() 的时候,是直接把手指触点 A、B、C 给连起来。而钢笔工具要实现这三个点间的流畅过渡,就只能将这两个线段的中间点做为起始点和结束点,而将手指的倒数第二个触点 B 做为控制点。

大家可能会觉得,那这样,在结束的时候,A 到 P0 和 P1 到 C1 的这段距离岂不是没画进去?是的,如果 Path 最终没有 close 的话,这两段距离是被抛弃掉的。因为手指间滑动时,每两个点间的距离很小,所以 P1 到 C 之间的距离可以忽略不计。

下面我们就利用这种方法在 photoshop 中求证,在连接多个线段时,是否能行?

在这个图形中,有很多点连成了弯弯曲曲的线段,我们利用上面我们讲的,将两个线段的中间做为二阶贝济埃曲线的起始点和终点,把上一个手指的位置做为控制点,来看看是否真的能组成平滑的连线
整个连接过程如动画所示:

在最终的路径中看来,各个点间的连线是非常平滑的。从这里也可以看出,在为了实现平滑效果,我们只能把开头的线段一半和结束的线段的一半抛弃掉。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
public class TestView extends View {
private Paint mPaint;
private Path mPath;
private float mPreX, mPreY;
public TestView(Context context, @Nullable AttributeSet attrs) {
super(context, attrs);
mPaint = new Paint();
mPaint.setColor(Color.GREEN);
mPaint.setStyle(Paint.Style.STROKE);
mPaint.setStrokeWidth(4);
mPath = new Path();
}
@Override
public boolean onTouchEvent(MotionEvent event) {
switch (event.getAction()){
case MotionEvent.ACTION_DOWN:
mPath.moveTo(event.getX(), event.getY());
mPreX = event.getX();
mPreY = event.getY();
return true;
case MotionEvent.ACTION_MOVE:
float endX = (mPreX + event.getX()) / 2;
float endY = (mPreY + event.getY()) / 2;
mPath.quadTo(mPreX, mPreY, endX, endY);
mPreX = event.getX();
mPreY = event.getY();
invalidate();
break;
default:
break;
}
return super.onTouchEvent(event);
}
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
canvas.drawPath(mPath, mPaint);
}
}

7.1.3 贝济埃曲线之 rQuadTo

1. 概述

1
public void rQuadTo(float dx1, float dy1, float dx2, float dy2)
  • dx1:控制点 X 坐标,表示相对上一个终点 X 坐标的位移坐标,可为负值,正值表示相加,负值表示相减。
  • dy1:控制点 Y 坐标,表示相对上一个终点 Y 坐标的位移坐标。同样可为负值,正值表示相加,负值表示相减。
  • dx2:终点 X 坐标,同样是一个相对坐标,相对上一个终点 X 坐标的位移值,可为负值,正值表示相加,负值表示相减。
  • dy2:终点 Y 坐标,同样是一个相对,相对上一个终点 Y 坐标的位移值。可为负值,正值表示相加,负值表示相减。

这四个参数都是传递的都是相对值,相对上一个终点的位移值。

比如,我们上一个终点坐标是 (300,400) 那么利用,rQuadTo(100,-100,200,100) 得到的控制点坐标是 (300+100, 400-100) 即 (500,300);同样,得到的终点坐标是 (300+200, 400+100),即 (500,500)。

1
2
3
4
5
6
7
// 利用 quadTo 定义一个绝对坐标:
path.moveTo(300,400);
path.quadTo(500,300,500,500);
// 与利用 rQuadTo 定义相对坐标是等价的:
path.moveTo(300,400);
path.rQuadTo(100,-100,200,100)

2. 使用 rQuadTo() 函数实现波浪线

1
2
3
4
5
6
7
8
9
mPath.moveTo(100, 300);
/*
mPath.quadTo(200, 200, 300, 300);
mPath.quadTo(400, 400, 500, 300);
*/
// 替换成
mPath.rQuadTo(100, -100, 200, 0);
mPath.rQuadTo(100, 100, 200, 0);
canvas.drawPath(mPath, mPaint);

第一句:path.rQuadTo(100,-100,200,0); 是建立在 (100,300) 这个点基础上来计算相对坐标的,所以:
控制点X坐标 = 上一个终点X坐标 + 控制点X位移 = 100+100 = 200;
控制点Y坐标 = 上一个终点Y坐标 + 控制点Y位移 = 300-100 = 200;
终点X坐标 = 上一个终点X坐标 + 终点X位移 = 100+200 = 300;
终点Y坐标 = 上一个终点Y坐标 + 终点Y位移 = 300+0 = 300;
所以这句与 path.quadTo(200,200,300,300); 对等的。

第二句:path.rQuadTo(100,100,200,0); 是建立在它的前一个终点即 (300,300) 的基础上来计算相对坐标的,所以:
控制点X坐标 = 上一个终点X坐标 + 控制点X位移 = 300+100 = 200;
控制点Y坐标 = 上一个终点Y坐标 + 控制点Y位移 = 300+100 = 200;
终点X坐标 = 上一个终点X坐标 + 终点X位移 = 300+200 = 500;
终点Y坐标 = 上一个终点Y坐标 + 终点Y位移 = 300+0 = 300;
所以这句与 path.quadTo(400,400,500,300); 对等的。

最终效果也是一样的。

通过这个例子,只想让大家明白一点:rQuadTo(float dx1, float dy1, float dx2, float dy2) 中的位移坐标,都是以上一个终点位置为基准来做偏移的。

7.1.4 示例:波浪效果

我们将 mPath 的起始位置向左移一个波长,然后利用 for 循环画出当前屏幕中可能容得下的所有波。然后画一个波的左右两个半波:

1
2
3
4
// 画的是一个波长中的前半个波
mPath.rQuadTo(halfWaveLen/2, -100, halfWaveLen, 0);
// 画的是一个波长中的后半个波
mPath.rQuadTo(halfWaveLen/2, 100, halfWaveLen, 0);

大家在这里可以看到,屏幕左右都多画了一个波长的图形。这是为了波形移动做准备的。

让波纹动起来其实挺简单,利用调用在 path.moveTo 的时候,将起始点向右移动即可实现移动,而且只要我们移动一个波长的长度,波纹就会重合,就可以实现无限循环了。

完整的代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
public class TestView extends View {
private Paint mPaint;
private Path mPath;
private int mItemWaveLength = 1000;
private int dx;
public TestView(Context context, AttributeSet attrs) {
super(context, attrs);
mPath = new Path();
mPaint = new Paint();
mPaint.setColor(Color.GREEN);
mPaint.setStyle(Paint.Style.FILL_AND_STROKE);
}
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
mPath.reset();
int originY = 300;
int halfWaveLen = mItemWaveLength/2;
mPath.moveTo(-mItemWaveLength+dx,originY);
for (int i = -mItemWaveLength; i<=getWidth()+mItemWaveLength; i+=mItemWaveLength){
mPath.rQuadTo(halfWaveLen/2f,-100,halfWaveLen,0);
mPath.rQuadTo(halfWaveLen/2f,100,halfWaveLen,0);
}
mPath.lineTo(getWidth(),getHeight());
mPath.lineTo(0,getHeight());
mPath.close();
canvas.drawPath(mPath,mPaint);
}
public void startAnim(){
ValueAnimator animator = ValueAnimator.ofInt(0,mItemWaveLength);
animator.setDuration(2000);
animator.setRepeatCount(ValueAnimator.INFINITE);
animator.setInterpolator(new LinearInterpolator());
animator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
@Override
public void onAnimationUpdate(ValueAnimator animation) {
dx = (int)animation.getAnimatedValue();
postInvalidate();
}
});
animator.start();
}
}

使用 TestView

1
2
3
4
5
6
7
8
9
10
11
public class MainActivity extends AppCompatActivity {
@Override
public void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.act_main);
TestView view = findViewById(R.id.view);
view.startAnim();
}
}